﻿using System;
using System.Linq;
using System.Web;
using System.Web.Caching;
using System.IO;
using System.Web.UI;
using System.Text;
using System.Collections.Generic;

using Filter = System.Predicate<string>;
using Default = System.ComponentModel.DefaultValueAttribute;

namespace System.Web.ScriptBuilder
{
	// не отслеживает добавление файлов
	public abstract class ScriptLinkerBase : IHttpHandler
	{
		public static string DefaultCacheVaryByParameter { get; set; }

		private object m_syncLock = new object();
		private HttpContext m_context;
		private int m_bufferSize = 30 * 1024; // 30kb
		private StringBuilder m_mergedCode;
		private List<CacheDependency> m_cacheDependencies;
		private List<ScriptChunk> m_chunks = new List<ScriptChunk>(100);
		private string m_pathOverride;

		public HttpContext Context { get { return m_context; } }

		public bool CachingEnabled { get; set; }
        public bool CompressionEnabled { get; set; }
		public int? CacheDuration { get; set; }
		public OutputCacheLocation? CacheLocation { get; set; }
		public bool? CacheNoStore { get; set; }
		public string CacheVaryByContentEncoding { get; set; }
		public string CacheVaryByHeader { get; set; }
		public string CacheVaryByParam { get; set; }

		public int StringBufferSize { get { return m_bufferSize; } set { if (value <= 0) throw new ArgumentOutOfRangeException("value"); m_bufferSize = value; } }
		public string ContentType { get; set; }
		public bool SkipCompressionErrors { get; set; }

		public ScriptCompressor Compressor { get; set; }
		public Dictionary<string, string> CompressorSettings { get; set; }

		public ScriptLinkerBase()
		{
			ContentType = "application/x-javascript";
#if DEBUG
			SkipCompressionErrors = true;
			CachingEnabled = false;
            CompressionEnabled = false;
#else
			CacheLocation = OutputCacheLocation.Server;
			SkipCompressionErrors = false;
			CachingEnabled = true;
            CompressionEnabled = true;
			this.CacheVaryByParam = DefaultCacheVaryByParameter;
#endif

        }

		#region IHttpHandler Members

		public bool IsReusable { get { return true; } }

		public void ProcessRequest(HttpContext context)
		{
			CollectScriptsSync(context);

			var req = context.Request;
			var resp = context.Response;

			resp.ContentType = this.ContentType;

			SetCacheHeaders(resp);
			if (string.Equals(req.HttpMethod, "HEAD"))
			{
				resp.AddHeader("Content-Length", MeasureCodeForOutput(resp).ToString());
			}
			else if (string.Equals(req.HttpMethod, "OPTIONS"))
			{
				resp.AddHeader("Public", "GET, HEAD, OPTIONS");
				resp.AddHeader("Allow", "GET, HEAD, OPTIONS");
				resp.AddHeader("Content-Length", MeasureCodeForOutput(resp).ToString());
			}
			else if (string.Equals(req.HttpMethod, "GET"))
			{
                ApplyCompression(resp, req);
				WriteCodeToOutput(resp);
			}
			else
			{
				resp.StatusCode = 405;
				resp.StatusDescription = "Method Not Allowed";
				resp.AddHeader("Allow", "GET, HEAD, OPTIONS");
			}
		}

		private void ApplyCompression(HttpResponse resp, HttpRequest req)
		{
            if (!this.CompressionEnabled)
                return;

			var acceptEncoding = req.Headers["Accept-Encoding"];
			var range = req.Headers["Range"];
			var prevUncompressedStream = resp.Filter;

			if (string.IsNullOrEmpty(acceptEncoding) || !string.IsNullOrEmpty(range))
				return;

			if (acceptEncoding.IndexOf("gzip", StringComparison.OrdinalIgnoreCase) != -1 || acceptEncoding == "*")
			{
				// gzip
				resp.Filter = new System.IO.Compression.GZipStream(prevUncompressedStream,
					System.IO.Compression.CompressionMode.Compress);
				resp.AppendHeader("Content-Encoding", "gzip");
			}
			else if (acceptEncoding.IndexOf("deflate", StringComparison.OrdinalIgnoreCase) != -1)
			{
				// defalte
				resp.Filter = new System.IO.Compression.DeflateStream(prevUncompressedStream,
					System.IO.Compression.CompressionMode.Compress);
				resp.AppendHeader("Content-Encoding", "deflate");
			}


		}
		private void SetCacheHeaders(HttpResponse resp)
		{
			if (!this.CachingEnabled)
				return;

			var s_varySeparator = new char[] { ';' };

			HttpCachePolicy cache = resp.Cache;
			OutputCacheLocation cacheLoc = ~OutputCacheLocation.Any;
			int cacheDuration = 0;
			string varyByContentEncoding = null;
			string varyByHeader = null;
			string varyByParam = null;
			bool noStore = this.CacheNoStore != null && this.CacheNoStore.Value;

			HttpCacheability cacheability;
			if (this.CacheDuration != null)
				cacheDuration = this.CacheDuration.Value;
			if (this.CacheVaryByContentEncoding != null)
				varyByContentEncoding = this.CacheVaryByContentEncoding;
			if (this.CacheVaryByHeader != null)
				varyByHeader = this.CacheVaryByHeader;
			if (this.CacheVaryByParam != null)
				varyByParam = this.CacheVaryByParam;
			if (this.CacheNoStore != null)
				noStore = this.CacheNoStore.Value;
			if (this.CacheLocation != null)
				cacheLoc = this.CacheLocation.Value;

			if (cacheLoc == ~OutputCacheLocation.Any)
				cacheLoc = OutputCacheLocation.Any;

			if (noStore)
				resp.Cache.SetNoStore();

			switch (cacheLoc)
			{
				case OutputCacheLocation.Any:
					cacheability = HttpCacheability.Public;
					break;

				case OutputCacheLocation.Client:
					cacheability = HttpCacheability.Private;
					break;

				case OutputCacheLocation.Downstream:
					cacheability = HttpCacheability.Public;
					cache.SetNoServerCaching();
					break;

				case OutputCacheLocation.Server:
					cacheability = HttpCacheability.Server;
					break;

				case OutputCacheLocation.None:
					cacheability = HttpCacheability.NoCache;
					break;

				case OutputCacheLocation.ServerAndClient:
					cacheability = HttpCacheability.ServerAndPrivate;
					break;
				default:
					cacheability = HttpCacheability.NoCache;
					break;
			}

			cache.SetCacheability(cacheability);
			if (cacheLoc != OutputCacheLocation.None)
			{
				if (cacheDuration > 0)
				{
					cache.SetExpires(this.Context.Timestamp.AddSeconds((double)cacheDuration));
					cache.SetMaxAge(new TimeSpan(0, 0, cacheDuration));
					cache.SetValidUntilExpires(true);
				}

				if (cacheLoc != OutputCacheLocation.Client)
				{
					if (varyByContentEncoding != null)
					{
						foreach (string enc in varyByContentEncoding.Split(s_varySeparator))
						{
							cache.VaryByContentEncodings[enc.Trim()] = true;
						}
					}
					if (varyByHeader != null)
					{
						foreach (string head in varyByHeader.Split(s_varySeparator))
						{
							cache.VaryByHeaders[head.Trim()] = true;
						}
					}
					if (cacheLoc != OutputCacheLocation.Downstream)
					{
						if (!string.IsNullOrEmpty(varyByParam))
						{
							foreach (string param in varyByParam.Split(s_varySeparator))
							{
								cache.VaryByParams[param.Trim()] = true;
							}
						}
					}
				}
			}

			if (m_cacheDependencies != null && m_cacheDependencies.Count > 0)
			{
				resp.AddCacheDependency(m_cacheDependencies.ToArray());
				resp.Cache.SetETagFromFileDependencies();
				resp.Cache.SetLastModifiedFromFileDependencies();
			}
		}
		private void WriteCodeToOutput(HttpResponse resp)
		{
			if (m_mergedCode == null)
				WriteAllToBuffer();

			resp.Output.Write(m_mergedCode);
		}
		private int MeasureCodeForOutput(HttpResponse resp)
		{
			if (m_mergedCode == null)
				WriteAllToBuffer();

			return m_mergedCode.Length;
		}
		#endregion

		public abstract void CollectScripts();
		private void CollectScriptsSync(HttpContext context)
		{
			lock (m_syncLock)
			{
				if (m_mergedCode != null && !IsModified())
					return;

				this.DisposeCacheDependencies();

				m_context = context;
				m_cacheDependencies = null;
				m_mergedCode = null;

				CollectScripts();

				EvaluateCodeDependencies();
				EnsureBufferSize();

				AddCacheDependency(new CacheDependency(m_pathOverride ?? this.Context.Request.PhysicalPath));

				m_context = null;
			}
		}

		/// <summary>
		/// Добавить множество файлов. 
		/// <para>При указании директории, дополнительно берутся файлы из всех поддиректорий.
		/// Для ограничения выборки используется список исключений.</para>
		/// <para>Файлы добавляются в алфавитном порядке</para>
		/// </summary>
		/// <param name="include">Список файлов. Путь до файла, либо путь до директории и маска файлов. Не может быть null</param>
		/// <param name="exclude">Список исключений. Путь до файла, либо путь до директории и маска файлов.</param>
		/// <param name="pathSortComparer">Сортировщик путей, для случаев когда стандартная сортировка по алфавиту не подходит. По умолчанию <see cref="StringComparer.CurrentCultureIgnoreCase"/></param>
		/// <param name="encoding">Кодировка файлов. По умолчанию utf-8</param>
		/// <example>
		/// <code>
		///		public override void CollectScripts()
		///		{
		///			this.AddFiles(new string[] {
		///				"~/scripts/utils/util.js",
		///				"~/scripts/home/*.js",
		///				"~/scripts/views/"
		///			}, new string[] {
		///				"~/scripts/views/*.css"
		///			});
		///		}
		/// </code>
		/// </example>
		public void AddFiles(string[] include, [Default(null)] string[] exclude, [Default(null)]IComparer<string> pathSortComparer, [Default(null)]Encoding encoding)
		{
			if (include == null)
				throw new ArgumentNullException("include");

			if (pathSortComparer == null)
				pathSortComparer = StringComparer.CurrentCultureIgnoreCase;

			var files = new List<string>(100);
			var dirs = new List<string>(20);
			var filesSize = (10L * 1024L); // размер найденных файлов +10кб

			// построить предикат из списка исключений
			var filter = PathUtils.CreateFilter(this.ToAbsolute(exclude));

			for (int i = 0; i < include.Length; i++)
			{
				var path = include[i];

				// пропускать пустые записи без ошибки
				if (string.IsNullOrEmpty(path))
					continue;

				path = ToAbsolute(path);

				foreach (var file in PathUtils.EnumerateFilesAt(path, pathSortComparer))
				{
					// проверить в списке исключений
					if (filter(file))
						continue;

					// добавить файл
					files.Add(file);
					filesSize += new FileInfo(file).Length;
				}

				var kind = PathUtils.GetPathKind(path);
				if (kind == PathKind.DirectoryPath || kind == PathKind.DirectoryWithMask)
					dirs.Add(path);
			}

			// для двухбайтовой кодировки символьный буффер(а он у нас такой) нужен в 2 раза меньше
			if (encoding != null && !encoding.IsSingleByte)
				filesSize /= 2;

			// добавляем код в буффер
			for (int i = 0; i < files.Count; i++)
			{
				AddFileInternal(files[i], encoding);
			}

			// добавляем файловые зависимости(на случай изменения файлов)
			if (files.Count > 0)
				this.AddCacheDependency(new CacheDependency(files.ToArray()));
			// добавляем зависимости от директорий(на случай добавления файлов)
			for (int i = 0; i < dirs.Count; i++)
				this.AddCacheDependency(new DirectoryCacheDependency(dirs[i], true, filter));
		}
		/// <summary>
		/// Добавить все файлы в директории. Опционально можно указать маску файлов.
		/// Этот метод не берет файлы из поддиректоррий.
		/// </summary>
		/// <param name="directoryPath">Путь до директории, может быть: абсолютным, относительных корня приложения, либо виртуальным(начинаться с ~). Не может быть null</param>
		/// <param name="encoding">Кодировка файлов. По умолчанию utf-8</param>
		public void AddDirectory(string directoryPath, [Default(null)]Encoding encoding)
		{
			if (directoryPath == null)
				throw new ArgumentNullException("directoryPath");

			var mask = (string)"*.*";
			PathUtils.SplitPathFromMask(ref directoryPath, ref mask);

			directoryPath = ToAbsolute(directoryPath);

			// нет ножек - нет мультиков
			if (!Directory.Exists(directoryPath))
				return;

			var files = new List<string>(100);
			var filesSize = (10L * 1024L); // размер найденных файлов +10кб

			foreach (var file in Directory.GetFiles(directoryPath, mask))
			{
				// добавить файл
				files.Add(file);
				filesSize += new FileInfo(file).Length;
			}

			// соединить путь до папки с маской для создания DirectoryCacheDependency
			if (mask != null)
			{
				if (directoryPath[directoryPath.Length - 1] == Path.DirectorySeparatorChar)
					directoryPath = directoryPath + mask;
				else
					directoryPath = string.Concat(directoryPath, Path.DirectorySeparatorChar, mask);
			}

			// добавляем код в буффер
			for (int i = 0; i < files.Count; i++)
			{
				AddFileInternal(files[i], encoding);
			}

			// добавляем файловые зависимости(на случай изменения файлов)
			if (files.Count > 0)
				this.AddCacheDependency(new CacheDependency(files.ToArray()));
			// добавляем зависимости от директорий(на случай добавления файлов)
			AddCacheDependency(new DirectoryCacheDependency(directoryPath, false));
		}
		/// <summary>
		/// Добавить файл. При этом создается зависимость кеша от файла
		/// </summary>
		/// <param name="path">Путь до файла, может быть: абсолютным, относительных корня приложения, либо виртуальным(начинаться с ~). Не может быть null</param>
		/// <param name="encoding">Кодировка файла. По умолчанию utf-8</param>
		public void AddFile(string path, [Default(null)]Encoding encoding)
		{
			if (string.IsNullOrEmpty(path))
				throw new ArgumentNullException("path");

			path = ToAbsolute(path);

			if (!File.Exists(path))
				throw new FileNotFoundException(Loc.GetString("Exception_ScriptFileNotFound"), path);

			AddFileInternal(path, encoding);

			AddCacheDependency(new CacheDependency(path));
		}
		/// <summary>
		/// Добавить другой <see cref="ScriptLinkerBase"/> как источник кода
		/// </summary>
		/// <param name="virtualPath">Виртуальный путь до другого <see cref="ScriptLinkerBase"/></param>
		public void AddLinker(string virtualPath)
		{
			var handler = (ScriptLinkerBase)System.Web.Compilation.BuildManager.CreateInstanceFromVirtualPath(virtualPath, typeof(ScriptLinkerBase));
			if (handler == null)
				throw new ArgumentException(Loc.GetString("Exception_HandlerIsNotFoundOrHasWrongType", virtualPath), "virtualPath");

			var chks = handler.GetChunks(this.Context, ToAbsolute(virtualPath));
			var deps = handler.GetCacheDependencies();

			this.AddCacheDependencies(deps);

			m_chunks.AddRange(chks);
		}
		/// <summary>
		/// Добавить поток, в котором содержится код
		/// </summary>
		/// <param name="stream">Поток с кодом. Не может быть null</param>
		/// <param name="encoding">Кодировка потока. По умолчанию utf-8</param>
		public void AddStream(Stream stream, [Default(null)]Encoding encoding)
		{
			if (stream == null)
				throw new ArgumentNullException("stream");
			if (encoding == null)
				encoding = Encoding.UTF8;

			stream.Position = 0;
			var reader = new StreamReader(stream, encoding);

			AddCode(reader.ReadToEnd());
		}
		/// <summary>
		/// Добавить строку с кодом
		/// </summary>
		/// <param name="code">Строка с кодом. Не может быть null</param>
		public void AddCode(string code)
		{
			if (code == null)
				throw new ArgumentNullException("code");

			m_chunks.Add(new ScriptChunk(code));
		}
		/// <summary>
		/// Добавить зависимость кеша. По умолчанию для всех добавляемых файлов создаются <see cref="CacheDependency"/>
		/// </summary>
		/// <param name="dependency">Новая зависимость. Не может быть null</param>
		public void AddCacheDependency(CacheDependency dependency)
		{
			if (dependency == null)
				throw new ArgumentNullException("dependency");

			if (m_cacheDependencies == null)
				m_cacheDependencies = new List<CacheDependency>();

			m_cacheDependencies.Add(dependency);
		}
		/// <summary>
		/// Добавить зависимость кеша. По умолчанию для всех добавляемых файлов создаются <see cref="CacheDependency"/>
		/// </summary>
		/// <param name="dependencies">Список зависимостей. Не может быть null</param>
		public void AddCacheDependencies(params CacheDependency[] dependencies)
		{
			if (dependencies == null)
				return;
			for (int i = 0; i < dependencies.Length; i++)
			{
				AddCacheDependency(dependencies[i]);
			}
		}

		protected virtual CacheDependency[] GetCacheDependencies()
		{
			if (m_cacheDependencies == null)
				return new CacheDependency[0];

			return m_cacheDependencies.ToArray();
		}
		protected virtual string OnPostProcessing(string code)
		{
			try
			{
				if (this.Compressor == null)
					return code;
				if (string.IsNullOrEmpty(code))
					return code;

				var errors = new StringBuilder(10 * 1024);
#if DEBUG
				var action = (Action<string>)(msg => errors.Append("/*<error>").Append(msg).AppendLine("</error>*/"));
#else
				var action = (Action<string>)(msg => { /* nooooone */ });
#endif
				code = this.Compressor.Compress(code, this.CompressorSettings, action);

				if (errors.Length > 0)
					code = string.Concat(errors.ToString(), code);
			}
			catch (OutOfMemoryException) { throw; }
			catch (Threading.ThreadAbortException) { throw; }
			catch (Exception)
			{
#if DEBUG
				System.Diagnostics.Debugger.Break();
#endif

				if (!this.SkipCompressionErrors)
					throw;
			}
			return code;
		}

		private void AddFileInternal(string path, Encoding encoding)
		{
			if (encoding == null)
				encoding = Encoding.UTF8;

			foreach (var chunk in m_chunks)
			{
				if (chunk.SourcePath == path)
					return; // уже есть в списке
			}

			using (var stream = File.OpenRead(path))
			{
				stream.Position = 0;
				var reader = new StreamReader(stream, encoding);
				var code = reader.ReadToEnd();

				m_chunks.Add(new ScriptChunk(code, path));
			}
		}
		private bool IsModified()
		{
			if (m_cacheDependencies == null)
				return false;
			foreach (var dep in m_cacheDependencies)
			{
				if (dep.HasChanged)
					return true;
			}
			return false;
		}
		private string ToAbsolute(string path)
		{
			if (path.StartsWith("~"))
			{
				if (path.Contains('*'))
				{
					var mask = string.Empty;
					PathUtils.SplitPathFromMask(ref path, ref mask);
					path = System.Web.Hosting.HostingEnvironment.MapPath(path);
					path = path + mask;
				}
				else
					path = System.Web.Hosting.HostingEnvironment.MapPath(path);
			}
			else if (!Path.IsPathRooted(path))
			{
				var handlerRoot = Path.GetDirectoryName(m_pathOverride ?? this.Context.Request.PhysicalPath);
				path = Path.Combine(handlerRoot, path);
			}
			return path;
		}
		private string[] ToAbsolute(string[] paths)
		{
			if (paths == null || paths.Length == 0)
				return new string[0];

			for (int i = 0; i < paths.Length; i++)
			{
				if (string.IsNullOrEmpty(paths[i]))
					continue;
				paths[i] = ToAbsolute(paths[i]);
			}
			return paths;
		}
		private void DisposeCacheDependencies()
		{
			if (m_cacheDependencies == null)
				return;

			for (int i = 0; i < m_cacheDependencies.Count; i++)
				m_cacheDependencies[i].Dispose();
		}
		private List<ScriptChunk> GetChunks(HttpContext context, string pathOverride)
		{
			m_pathOverride = pathOverride;
			CollectScriptsSync(context);
			m_pathOverride = null;

			return m_chunks;
		}
		private void WriteAllToBuffer()
		{
			if (m_mergedCode == null)
				m_mergedCode = new StringBuilder(this.StringBufferSize);
			else
				m_mergedCode.Length = 0;

			foreach (var chunk in m_chunks)
			{
				chunk.WriteTo(m_mergedCode);
			}

			// post process
			var code = m_mergedCode.ToString();
			code = OnPostProcessing(code);

			m_mergedCode.Length = 0;
			m_mergedCode.Append(code);
		}
		private void EvaluateCodeDependencies()
		{
			var chunks = new Dictionary<string, ScriptChunk>(m_chunks.Count);
			var chunkDeps = new Dictionary<ScriptChunk, ScriptChunk[]>();

			// собрать кусочки 
			for (int i = 0; i < m_chunks.Count; i++)
			{
				if (string.IsNullOrEmpty(m_chunks[i].SourcePath))
					continue;

				m_chunks[i].Order = i;
				chunks[m_chunks[i].SourcePath] = m_chunks[i];
			}

			// материализовать зависимости
			for (int i = 0; i < m_chunks.Count; i++)
			{
				var chunk = m_chunks[i];
				var unresolvedDeps = (List<string>)null;
				var chunkDepsList = new ScriptChunk[chunk.Dependencies.Length];
				chunkDeps[chunk] = chunkDepsList;
				for (int d = 0; d < chunk.Dependencies.Length; d++)
				{
					var depend = chunk.Dependencies[d];
					var dependChunk = (ScriptChunk)(null);
					if (!chunks.TryGetValue(depend, out dependChunk))
					{
						if (unresolvedDeps == null)
							unresolvedDeps = new List<string>(chunk.Dependencies.Length);
						unresolvedDeps.Add(depend);
					}
					else
					{
						chunkDepsList[d] = dependChunk;
					}
				}
				if (unresolvedDeps != null)
					chunk.UnresolvedDependencies = unresolvedDeps.ToArray();
			}

			var found = (ushort)0;
			do
			{
				found = 0;
				for (int i = 0; i < m_chunks.Count; i++)
				{
					var chunk = m_chunks[i];
					var chunkDepsList = chunkDeps[chunk];

					if (chunk.DependencyDepth != -1)
						continue;

					var chunkDepth = (short)0;
					for (int d = 0; d < chunkDepsList.Length; d++)
					{
						var dependChunk = chunkDepsList[d];

						if (dependChunk == null || dependChunk == chunk)
							continue;
						if (dependChunk.DependencyDepth == -1)
							goto next;

						chunkDepth = Math.Max(chunkDepth, (short)(dependChunk.DependencyDepth + 1));
					}
					chunk.DependencyDepth = chunkDepth;

					found++;
				next:
					continue;
				}

			} while (found > 0);

			m_chunks.Sort((x, y) =>
			{
				if (x.DependencyDepth == y.DependencyDepth)
					return x.Order.CompareTo(y.Order);
				return x.DependencyDepth.CompareTo(y.DependencyDepth);
			});
		}
		private void EnsureBufferSize()
		{
			var bufferSize = 0;
			foreach (var chunk in m_chunks)
			{
				bufferSize += chunk.GetSize();
			}
			this.StringBufferSize = Math.Max(this.StringBufferSize, bufferSize);
		}
	}
}